diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala deleted file mode 100644 index 1211287..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines the area the color should apply to, eg. background, text, border, - * etc. - */ -enum ColorArea(val name: String): - case bg extends ColorArea("bg") - case text extends ColorArea("text") - case decoration extends ColorArea("decoration") - case border extends ColorArea("border") - case outline extends ColorArea("outline") - case divide extends ColorArea("divide") - case ring extends ColorArea("ring") - case ringOffset extends ColorArea("ring-offset") - case shadow extends ColorArea("shadow") - case accent extends ColorArea("accent") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala deleted file mode 100644 index 1211287..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines the area the color should apply to, eg. background, text, border, - * etc. - */ -enum ColorArea(val name: String): - case bg extends ColorArea("bg") - case text extends ColorArea("text") - case decoration extends ColorArea("decoration") - case border extends ColorArea("border") - case outline extends ColorArea("outline") - case divide extends ColorArea("divide") - case ring extends ColorArea("ring") - case ringOffset extends ColorArea("ring-offset") - case shadow extends ColorArea("shadow") - case accent extends ColorArea("accent") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala deleted file mode 100644 index 9c5ec61..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala +++ /dev/null @@ -1,43 +0,0 @@ -package works.iterative.ui.model.color - -/** A combination of ColorKind and ColorWeight, if applicable. - * - * By applying area we get the full Color definition. - */ -sealed trait ColorDef: - def toCSS: String - - def bg = Color(ColorArea.bg, this) - def text = Color(ColorArea.text, this) - def decoration = Color(ColorArea.decoration, this) - def border = Color(ColorArea.border, this) - def outline = Color(ColorArea.outline, this) - def divide = Color(ColorArea.divide, this) - def ring = Color(ColorArea.ring, this) - def ringOffset = Color(ColorArea.ringOffset, this) - def shadow = Color(ColorArea.shadow, this) - def accent = Color(ColorArea.accent, this) - -// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. -object ColorDef: - case class WeightedColorDef( - kind: ColorKind, - weight: ColorWeight - ) extends ColorDef: - override def toCSS: String = s"${kind.name}-${weight.value}" - - case class UnweightedColorDef( - kind: ColorKind - ) extends ColorDef: - override def toCSS: String = kind.name - - // TODO: check that the kind is valid unweighted kind - // that means current, inherit, auto, transparent, black, white - // tried using implicit evidence, but the type inference for enumerations - // tends to generalize to the enum, instead of the real type - def apply[T <: ColorKind](kind: T)(using - ev: T <:< ColorKind.Unweighted - ): ColorDef = - UnweightedColorDef(kind) - def apply(kind: ColorKind, weight: ColorWeight): ColorDef = - WeightedColorDef(kind, weight) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala deleted file mode 100644 index 1211287..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines the area the color should apply to, eg. background, text, border, - * etc. - */ -enum ColorArea(val name: String): - case bg extends ColorArea("bg") - case text extends ColorArea("text") - case decoration extends ColorArea("decoration") - case border extends ColorArea("border") - case outline extends ColorArea("outline") - case divide extends ColorArea("divide") - case ring extends ColorArea("ring") - case ringOffset extends ColorArea("ring-offset") - case shadow extends ColorArea("shadow") - case accent extends ColorArea("accent") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala deleted file mode 100644 index 9c5ec61..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala +++ /dev/null @@ -1,43 +0,0 @@ -package works.iterative.ui.model.color - -/** A combination of ColorKind and ColorWeight, if applicable. - * - * By applying area we get the full Color definition. - */ -sealed trait ColorDef: - def toCSS: String - - def bg = Color(ColorArea.bg, this) - def text = Color(ColorArea.text, this) - def decoration = Color(ColorArea.decoration, this) - def border = Color(ColorArea.border, this) - def outline = Color(ColorArea.outline, this) - def divide = Color(ColorArea.divide, this) - def ring = Color(ColorArea.ring, this) - def ringOffset = Color(ColorArea.ringOffset, this) - def shadow = Color(ColorArea.shadow, this) - def accent = Color(ColorArea.accent, this) - -// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. -object ColorDef: - case class WeightedColorDef( - kind: ColorKind, - weight: ColorWeight - ) extends ColorDef: - override def toCSS: String = s"${kind.name}-${weight.value}" - - case class UnweightedColorDef( - kind: ColorKind - ) extends ColorDef: - override def toCSS: String = kind.name - - // TODO: check that the kind is valid unweighted kind - // that means current, inherit, auto, transparent, black, white - // tried using implicit evidence, but the type inference for enumerations - // tends to generalize to the enum, instead of the real type - def apply[T <: ColorKind](kind: T)(using - ev: T <:< ColorKind.Unweighted - ): ColorDef = - UnweightedColorDef(kind) - def apply(kind: ColorKind, weight: ColorWeight): ColorDef = - WeightedColorDef(kind, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala deleted file mode 100644 index ea24372..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines what color should be used, without specifying the area or weight. - */ -sealed abstract class ColorKind private (val name: String): - def apply(weight: ColorWeight): ColorDef = - ColorDef.WeightedColorDef(this, weight) - -object ColorKind: - trait Unweighted: - self: ColorKind => - override def apply(weight: ColorWeight): ColorDef = - ColorDef.UnweightedColorDef(self) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case object current extends ColorKind("current") with Unweighted - case object inherit extends ColorKind("inherit") with Unweighted - // Not present in for all methods - case object transp extends ColorKind("transparent") with Unweighted - // Seen in accent, not preset otherwise - case object auto extends ColorKind("auto") with Unweighted - // Black and white do not have weight - case object black extends ColorKind("black") with Unweighted - case object white extends ColorKind("white") with Unweighted - case object slate extends ColorKind("slate") - case object gray extends ColorKind("gray") - case object zinc extends ColorKind("zinc") - case object neutral extends ColorKind("neutral") - case object stone extends ColorKind("stone") - case object red extends ColorKind("red") - case object orange extends ColorKind("orange") - case object amber extends ColorKind("amber") - case object yellow extends ColorKind("yellow") - case object lime extends ColorKind("lime") - case object green extends ColorKind("green") - case object emerald extends ColorKind("emerald") - case object teal extends ColorKind("teal") - case object cyan extends ColorKind("cyan") - case object sky extends ColorKind("sky") - case object blue extends ColorKind("blue") - case object indigo extends ColorKind("indigo") - case object violet extends ColorKind("violet") - case object purple extends ColorKind("purple") - case object fuchsia extends ColorKind("fuchsia") - case object pink extends ColorKind("pink") - case object rose extends ColorKind("rose") diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala deleted file mode 100644 index 1211287..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines the area the color should apply to, eg. background, text, border, - * etc. - */ -enum ColorArea(val name: String): - case bg extends ColorArea("bg") - case text extends ColorArea("text") - case decoration extends ColorArea("decoration") - case border extends ColorArea("border") - case outline extends ColorArea("outline") - case divide extends ColorArea("divide") - case ring extends ColorArea("ring") - case ringOffset extends ColorArea("ring-offset") - case shadow extends ColorArea("shadow") - case accent extends ColorArea("accent") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala deleted file mode 100644 index 9c5ec61..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala +++ /dev/null @@ -1,43 +0,0 @@ -package works.iterative.ui.model.color - -/** A combination of ColorKind and ColorWeight, if applicable. - * - * By applying area we get the full Color definition. - */ -sealed trait ColorDef: - def toCSS: String - - def bg = Color(ColorArea.bg, this) - def text = Color(ColorArea.text, this) - def decoration = Color(ColorArea.decoration, this) - def border = Color(ColorArea.border, this) - def outline = Color(ColorArea.outline, this) - def divide = Color(ColorArea.divide, this) - def ring = Color(ColorArea.ring, this) - def ringOffset = Color(ColorArea.ringOffset, this) - def shadow = Color(ColorArea.shadow, this) - def accent = Color(ColorArea.accent, this) - -// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. -object ColorDef: - case class WeightedColorDef( - kind: ColorKind, - weight: ColorWeight - ) extends ColorDef: - override def toCSS: String = s"${kind.name}-${weight.value}" - - case class UnweightedColorDef( - kind: ColorKind - ) extends ColorDef: - override def toCSS: String = kind.name - - // TODO: check that the kind is valid unweighted kind - // that means current, inherit, auto, transparent, black, white - // tried using implicit evidence, but the type inference for enumerations - // tends to generalize to the enum, instead of the real type - def apply[T <: ColorKind](kind: T)(using - ev: T <:< ColorKind.Unweighted - ): ColorDef = - UnweightedColorDef(kind) - def apply(kind: ColorKind, weight: ColorWeight): ColorDef = - WeightedColorDef(kind, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala deleted file mode 100644 index ea24372..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines what color should be used, without specifying the area or weight. - */ -sealed abstract class ColorKind private (val name: String): - def apply(weight: ColorWeight): ColorDef = - ColorDef.WeightedColorDef(this, weight) - -object ColorKind: - trait Unweighted: - self: ColorKind => - override def apply(weight: ColorWeight): ColorDef = - ColorDef.UnweightedColorDef(self) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case object current extends ColorKind("current") with Unweighted - case object inherit extends ColorKind("inherit") with Unweighted - // Not present in for all methods - case object transp extends ColorKind("transparent") with Unweighted - // Seen in accent, not preset otherwise - case object auto extends ColorKind("auto") with Unweighted - // Black and white do not have weight - case object black extends ColorKind("black") with Unweighted - case object white extends ColorKind("white") with Unweighted - case object slate extends ColorKind("slate") - case object gray extends ColorKind("gray") - case object zinc extends ColorKind("zinc") - case object neutral extends ColorKind("neutral") - case object stone extends ColorKind("stone") - case object red extends ColorKind("red") - case object orange extends ColorKind("orange") - case object amber extends ColorKind("amber") - case object yellow extends ColorKind("yellow") - case object lime extends ColorKind("lime") - case object green extends ColorKind("green") - case object emerald extends ColorKind("emerald") - case object teal extends ColorKind("teal") - case object cyan extends ColorKind("cyan") - case object sky extends ColorKind("sky") - case object blue extends ColorKind("blue") - case object indigo extends ColorKind("indigo") - case object violet extends ColorKind("violet") - case object purple extends ColorKind("purple") - case object fuchsia extends ColorKind("fuchsia") - case object pink extends ColorKind("pink") - case object rose extends ColorKind("rose") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala deleted file mode 100644 index 7767ac5..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative.ui.model.color - -opaque type ColorWeight = String - -extension (c: ColorWeight) def value: String = c - -/** Defines weight of a color, eg. 50, 100, 200, etc. - * - * Tailwind-like. - */ -object ColorWeight: - inline given int50: Conversion[50, ColorWeight] with - inline def apply(i: 50) = "50" - inline given int100: Conversion[100, ColorWeight] with - inline def apply(i: 100) = "100" - inline given int200: Conversion[200, ColorWeight] with - inline def apply(i: 200) = "200" - inline given int300: Conversion[300, ColorWeight] with - inline def apply(i: 300) = "300" - inline given int400: Conversion[400, ColorWeight] with - inline def apply(i: 400) = "400" - inline given int500: Conversion[500, ColorWeight] with - inline def apply(i: 500) = "500" - inline given int600: Conversion[600, ColorWeight] with - inline def apply(i: 600) = "600" - inline given int700: Conversion[700, ColorWeight] with - inline def apply(i: 700) = "700" - inline given int800: Conversion[800, ColorWeight] with - inline def apply(i: 800) = "800" - inline given int900: Conversion[900, ColorWeight] with - inline def apply(i: 900) = "900" - inline given int950: Conversion[950, ColorWeight] with - inline def apply(i: 950) = "950" diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala deleted file mode 100644 index 1211287..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines the area the color should apply to, eg. background, text, border, - * etc. - */ -enum ColorArea(val name: String): - case bg extends ColorArea("bg") - case text extends ColorArea("text") - case decoration extends ColorArea("decoration") - case border extends ColorArea("border") - case outline extends ColorArea("outline") - case divide extends ColorArea("divide") - case ring extends ColorArea("ring") - case ringOffset extends ColorArea("ring-offset") - case shadow extends ColorArea("shadow") - case accent extends ColorArea("accent") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala deleted file mode 100644 index 9c5ec61..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala +++ /dev/null @@ -1,43 +0,0 @@ -package works.iterative.ui.model.color - -/** A combination of ColorKind and ColorWeight, if applicable. - * - * By applying area we get the full Color definition. - */ -sealed trait ColorDef: - def toCSS: String - - def bg = Color(ColorArea.bg, this) - def text = Color(ColorArea.text, this) - def decoration = Color(ColorArea.decoration, this) - def border = Color(ColorArea.border, this) - def outline = Color(ColorArea.outline, this) - def divide = Color(ColorArea.divide, this) - def ring = Color(ColorArea.ring, this) - def ringOffset = Color(ColorArea.ringOffset, this) - def shadow = Color(ColorArea.shadow, this) - def accent = Color(ColorArea.accent, this) - -// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. -object ColorDef: - case class WeightedColorDef( - kind: ColorKind, - weight: ColorWeight - ) extends ColorDef: - override def toCSS: String = s"${kind.name}-${weight.value}" - - case class UnweightedColorDef( - kind: ColorKind - ) extends ColorDef: - override def toCSS: String = kind.name - - // TODO: check that the kind is valid unweighted kind - // that means current, inherit, auto, transparent, black, white - // tried using implicit evidence, but the type inference for enumerations - // tends to generalize to the enum, instead of the real type - def apply[T <: ColorKind](kind: T)(using - ev: T <:< ColorKind.Unweighted - ): ColorDef = - UnweightedColorDef(kind) - def apply(kind: ColorKind, weight: ColorWeight): ColorDef = - WeightedColorDef(kind, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala deleted file mode 100644 index ea24372..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines what color should be used, without specifying the area or weight. - */ -sealed abstract class ColorKind private (val name: String): - def apply(weight: ColorWeight): ColorDef = - ColorDef.WeightedColorDef(this, weight) - -object ColorKind: - trait Unweighted: - self: ColorKind => - override def apply(weight: ColorWeight): ColorDef = - ColorDef.UnweightedColorDef(self) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case object current extends ColorKind("current") with Unweighted - case object inherit extends ColorKind("inherit") with Unweighted - // Not present in for all methods - case object transp extends ColorKind("transparent") with Unweighted - // Seen in accent, not preset otherwise - case object auto extends ColorKind("auto") with Unweighted - // Black and white do not have weight - case object black extends ColorKind("black") with Unweighted - case object white extends ColorKind("white") with Unweighted - case object slate extends ColorKind("slate") - case object gray extends ColorKind("gray") - case object zinc extends ColorKind("zinc") - case object neutral extends ColorKind("neutral") - case object stone extends ColorKind("stone") - case object red extends ColorKind("red") - case object orange extends ColorKind("orange") - case object amber extends ColorKind("amber") - case object yellow extends ColorKind("yellow") - case object lime extends ColorKind("lime") - case object green extends ColorKind("green") - case object emerald extends ColorKind("emerald") - case object teal extends ColorKind("teal") - case object cyan extends ColorKind("cyan") - case object sky extends ColorKind("sky") - case object blue extends ColorKind("blue") - case object indigo extends ColorKind("indigo") - case object violet extends ColorKind("violet") - case object purple extends ColorKind("purple") - case object fuchsia extends ColorKind("fuchsia") - case object pink extends ColorKind("pink") - case object rose extends ColorKind("rose") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala deleted file mode 100644 index 7767ac5..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative.ui.model.color - -opaque type ColorWeight = String - -extension (c: ColorWeight) def value: String = c - -/** Defines weight of a color, eg. 50, 100, 200, etc. - * - * Tailwind-like. - */ -object ColorWeight: - inline given int50: Conversion[50, ColorWeight] with - inline def apply(i: 50) = "50" - inline given int100: Conversion[100, ColorWeight] with - inline def apply(i: 100) = "100" - inline given int200: Conversion[200, ColorWeight] with - inline def apply(i: 200) = "200" - inline given int300: Conversion[300, ColorWeight] with - inline def apply(i: 300) = "300" - inline given int400: Conversion[400, ColorWeight] with - inline def apply(i: 400) = "400" - inline given int500: Conversion[500, ColorWeight] with - inline def apply(i: 500) = "500" - inline given int600: Conversion[600, ColorWeight] with - inline def apply(i: 600) = "600" - inline given int700: Conversion[700, ColorWeight] with - inline def apply(i: 700) = "700" - inline given int800: Conversion[800, ColorWeight] with - inline def apply(i: 800) = "800" - inline given int900: Conversion[900, ColorWeight] with - inline def apply(i: 900) = "900" - inline given int950: Conversion[950, ColorWeight] with - inline def apply(i: 950) = "950" diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala deleted file mode 100644 index 2b380e6..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.model - -/** We need a generic Color model that can be used both on server and on client. - * - * We have adopted the Tailwind model for now. There is nothing inherently - * Tailwind-specific in the implementation, but all the values are taken from - * their palette and the area model is very HTML biased. - * - * Still, I think that it is a good starting point for exploration and will - * satisfy our current needs. - */ -package object color {} diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala deleted file mode 100644 index 1211287..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines the area the color should apply to, eg. background, text, border, - * etc. - */ -enum ColorArea(val name: String): - case bg extends ColorArea("bg") - case text extends ColorArea("text") - case decoration extends ColorArea("decoration") - case border extends ColorArea("border") - case outline extends ColorArea("outline") - case divide extends ColorArea("divide") - case ring extends ColorArea("ring") - case ringOffset extends ColorArea("ring-offset") - case shadow extends ColorArea("shadow") - case accent extends ColorArea("accent") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala deleted file mode 100644 index 9c5ec61..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala +++ /dev/null @@ -1,43 +0,0 @@ -package works.iterative.ui.model.color - -/** A combination of ColorKind and ColorWeight, if applicable. - * - * By applying area we get the full Color definition. - */ -sealed trait ColorDef: - def toCSS: String - - def bg = Color(ColorArea.bg, this) - def text = Color(ColorArea.text, this) - def decoration = Color(ColorArea.decoration, this) - def border = Color(ColorArea.border, this) - def outline = Color(ColorArea.outline, this) - def divide = Color(ColorArea.divide, this) - def ring = Color(ColorArea.ring, this) - def ringOffset = Color(ColorArea.ringOffset, this) - def shadow = Color(ColorArea.shadow, this) - def accent = Color(ColorArea.accent, this) - -// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. -object ColorDef: - case class WeightedColorDef( - kind: ColorKind, - weight: ColorWeight - ) extends ColorDef: - override def toCSS: String = s"${kind.name}-${weight.value}" - - case class UnweightedColorDef( - kind: ColorKind - ) extends ColorDef: - override def toCSS: String = kind.name - - // TODO: check that the kind is valid unweighted kind - // that means current, inherit, auto, transparent, black, white - // tried using implicit evidence, but the type inference for enumerations - // tends to generalize to the enum, instead of the real type - def apply[T <: ColorKind](kind: T)(using - ev: T <:< ColorKind.Unweighted - ): ColorDef = - UnweightedColorDef(kind) - def apply(kind: ColorKind, weight: ColorWeight): ColorDef = - WeightedColorDef(kind, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala deleted file mode 100644 index ea24372..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines what color should be used, without specifying the area or weight. - */ -sealed abstract class ColorKind private (val name: String): - def apply(weight: ColorWeight): ColorDef = - ColorDef.WeightedColorDef(this, weight) - -object ColorKind: - trait Unweighted: - self: ColorKind => - override def apply(weight: ColorWeight): ColorDef = - ColorDef.UnweightedColorDef(self) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case object current extends ColorKind("current") with Unweighted - case object inherit extends ColorKind("inherit") with Unweighted - // Not present in for all methods - case object transp extends ColorKind("transparent") with Unweighted - // Seen in accent, not preset otherwise - case object auto extends ColorKind("auto") with Unweighted - // Black and white do not have weight - case object black extends ColorKind("black") with Unweighted - case object white extends ColorKind("white") with Unweighted - case object slate extends ColorKind("slate") - case object gray extends ColorKind("gray") - case object zinc extends ColorKind("zinc") - case object neutral extends ColorKind("neutral") - case object stone extends ColorKind("stone") - case object red extends ColorKind("red") - case object orange extends ColorKind("orange") - case object amber extends ColorKind("amber") - case object yellow extends ColorKind("yellow") - case object lime extends ColorKind("lime") - case object green extends ColorKind("green") - case object emerald extends ColorKind("emerald") - case object teal extends ColorKind("teal") - case object cyan extends ColorKind("cyan") - case object sky extends ColorKind("sky") - case object blue extends ColorKind("blue") - case object indigo extends ColorKind("indigo") - case object violet extends ColorKind("violet") - case object purple extends ColorKind("purple") - case object fuchsia extends ColorKind("fuchsia") - case object pink extends ColorKind("pink") - case object rose extends ColorKind("rose") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala deleted file mode 100644 index 7767ac5..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative.ui.model.color - -opaque type ColorWeight = String - -extension (c: ColorWeight) def value: String = c - -/** Defines weight of a color, eg. 50, 100, 200, etc. - * - * Tailwind-like. - */ -object ColorWeight: - inline given int50: Conversion[50, ColorWeight] with - inline def apply(i: 50) = "50" - inline given int100: Conversion[100, ColorWeight] with - inline def apply(i: 100) = "100" - inline given int200: Conversion[200, ColorWeight] with - inline def apply(i: 200) = "200" - inline given int300: Conversion[300, ColorWeight] with - inline def apply(i: 300) = "300" - inline given int400: Conversion[400, ColorWeight] with - inline def apply(i: 400) = "400" - inline given int500: Conversion[500, ColorWeight] with - inline def apply(i: 500) = "500" - inline given int600: Conversion[600, ColorWeight] with - inline def apply(i: 600) = "600" - inline given int700: Conversion[700, ColorWeight] with - inline def apply(i: 700) = "700" - inline given int800: Conversion[800, ColorWeight] with - inline def apply(i: 800) = "800" - inline given int900: Conversion[900, ColorWeight] with - inline def apply(i: 900) = "900" - inline given int950: Conversion[950, ColorWeight] with - inline def apply(i: 950) = "950" diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala deleted file mode 100644 index 2b380e6..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.model - -/** We need a generic Color model that can be used both on server and on client. - * - * We have adopted the Tailwind model for now. There is nothing inherently - * Tailwind-specific in the implementation, but all the values are taken from - * their palette and the area model is very HTML biased. - * - * Still, I think that it is a good starting point for exploration and will - * satisfy our current needs. - */ -package object color {} diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala b/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala new file mode 100644 index 0000000..70bebaf --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.model.tables + +/** A column in a table + * + * @param name + * the name of the column, must be unique in a row + * @param get + * a function to get the value of the column from a type + */ +case class Column[A, Cell](name: String, get: A => Cell) + +/** A typeclass to represet a type that can be tabulated into Cells */ +trait Tabular[A, Cell]: + def columns: List[Column[A, Cell]] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..0582cb5 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,25 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +class JsStorageRepository[Value: JsonCodec](storage: Storage) + extends Repository[String, Value]: + + override def find(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala new file mode 100644 index 0000000..00b64a8 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Codecs.scala @@ -0,0 +1,16 @@ +package works.iterative.core + +import zio.json.* + +trait Codecs: + given JsonCodec[Email] = + JsonCodec.string.transformOrFail( + Email(_).toEitherWith(_ => "Error parsing email"), + _.value + ) + given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( + PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), + _.asString + ) + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..2773e1b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,20 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileRef.scala b/core/shared/src/main/scala/works/iterative/core/FileRef.scala new file mode 100644 index 0000000..56bce04 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileRef.scala @@ -0,0 +1,9 @@ +package works.iterative.core + +/** Represents a reference to a file */ +case class FileRef( + name: String, + url: String, + fileType: Option[String], + size: Option[String] +) diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala new file mode 100644 index 0000000..711ec85 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/HasUserMessage.scala @@ -0,0 +1,4 @@ +package works.iterative.core + +trait HasUserMessage: + def userMessage: UserMessage diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala index 67bf96f..37bda09 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserMessage.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserMessage.scala @@ -4,7 +4,9 @@ // Type-wise naive solution for specifying user messages. // A mechanism that will check the message for correct formatting and validate parameters is needed // TODO: make UserMessage serializable -case class UserMessage(id: MessageId, args: Any*) +case class UserMessage(id: MessageId, args: Any*): + override def toString(): String = + s"${id}[${args.mkString(", ")}]" object UserMessage: given Conversion[MessageId, UserMessage] with diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..6a5c831 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,41 @@ +package works.iterative.core.auth + +import zio.* + +opaque type PermissionOp = String +object PermissionOp: + def apply(op: String): PermissionOp = op + + extension (op: PermissionOp) def value: String = op + +opaque type PermissionTarget = String +object PermissionTarget: + def apply(namespace: String, id: String): PermissionTarget = + require( + namespace.trim.nonEmpty && id.trim.nonEmpty, + "Both namespece and id must be defined" + ) + require( + namespace.indexOf(':') == -1 && id.indexOf(':') == -1, + "Neither namespace nor id can contain ':'" + ) + s"$namespace:$id" + + extension (target: PermissionTarget) + def namespace: String = target.split(":", 2).head + def value: String = target.split(":", 2).last + +trait PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: Option[UserInfo], + action: PermissionOp, + obj: PermissionTarget + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..dc7d292 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,15 @@ +package works.iterative.core.service + +import zio.* + +trait ReadRepository[-Key, +Value]: + type Op[A] = UIO[A] + def find(id: Key): Op[Option[Value]] + +trait WriteRepository[-Key, -Value]: + type Op[A] = UIO[A] + def save(key: Key, value: Value): Op[Unit] + +trait Repository[-Key, Value] + extends ReadRepository[Key, Value] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..3c83b59 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value](data: Ref[Map[Key, Value]]) + extends Repository[Key, Value]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def find(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) diff --git a/project/project/plugins.sbt b/project/project/plugins.sbt index 324e88a..eb56cda 100644 --- a/project/project/plugins.sbt +++ b/project/project/plugins.sbt @@ -3,5 +3,5 @@ resolvers += "IW snapshots" at "https://dig.iterative.works/maven/snapshots" addSbtPlugin( - "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.16" + "works.iterative.sbt" % "sbt-iw-plugin-presets" % "0.3.17" ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala index 66eb6e7..af9cf58 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -3,7 +3,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable given HtmlRenderable[File] with def toHtml(m: File): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala index cc4ee36..cdc03d6 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -2,14 +2,14 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.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 +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec object FileSelector: - import FilePicker._ + import FilePicker.* def apply( initialFiles: List[File], @@ -22,7 +22,7 @@ "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-7xl sm:align-middle" ), role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + htmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), aria.labelledBy("modal-headline"), div( cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), @@ -50,7 +50,7 @@ "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)( + onClick.compose( _.sample(selectedFiles) .map(SelectionUpdated(_)) ) --> selectionUpdates diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala index c665744..41cb94c 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -2,20 +2,20 @@ package components.tailwind import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import works.iterative.ui.components.tailwind.Icons import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.ZoneId import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import works.iterative.core.CzechSupport def FileTable( files: Signal[List[File]], maybeSelection: Option[Var[Set[File]]] = None ): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) + val scope = htmlAttr("scope", StringAsIsCodec) val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) val openCategories = Var[Set[String]]( maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala index 8de85e4..ce2e119 100644 --- a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -35,7 +35,7 @@ input( cls := "cursor-pointer hidden", tpe := "file", - name := "files", + nameAttr := "files", multiple := true, inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala index 79aef16..dc898f3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -5,6 +5,7 @@ import scala.scalajs.js import works.iterative.core.UserMessage import java.text.MessageFormat +import scala.util.Try // TODO: support hierarchical json structure trait JsonMessageCatalogue extends MessageCatalogue: @@ -16,4 +17,9 @@ override def get(msg: UserMessage): Option[String] = assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) + get(msg.id).map(m => + Try(m.format(msg.args*)).fold( + t => s"error formatting [${msg.id.toString()}]: '$m': ${t.getMessage}", + identity + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala new file mode 100644 index 0000000..36fd8ea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.components + +import works.iterative.core.MessageCatalogue + +trait ComponentContext[App]: + def app: App + def messages: MessageCatalogue + + def nested(prefixes: String*): ComponentContext[App] = + ComponentContext.Nested[App](this, prefixes) + +object ComponentContext: + case class Nested[App](parent: ComponentContext[App], prefixes: Seq[String]) + extends ComponentContext[App]: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala index 8e6cab2..56f3864 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -21,7 +21,7 @@ val state: Var[Boolean] = Var(initialValue) children( Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + onClick.compose(_.sample(state).map(v => !v)) --> state, el => state.signal.map { case true => el diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala index 9a54dea..bfa063a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -1,13 +1,11 @@ package works.iterative.ui.components.laminar import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.tags.HtmlTag case class Bin[Source, +Value]( label: String, description: Option[String | HtmlElement], - color: ColorDef, valueOf: Source => Value ): def map[NewValue]( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala deleted file mode 100644 index bf149dc..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ /dev/null @@ -1,90 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait ButtonComponentsModule: - def buttons: ButtonComponents - - trait ButtonComponents: - def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement - -trait DefaultButtonComponentsModule(using ctx: ComponentContext) - extends ButtonComponentsModule: - - override val buttons = new ButtonComponents: - - private inline def srHelp(id: String): Modifier[HtmlElement] = - ctx.messages - .opt(s"form.button.${id}.screenReaderHelp") - .map(sr => span(cls := "sr-only", sr)) - - override def primaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe := "submit", - cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - icon, - text, - mods - ) - - override def secondaryButton( - id: String, - text: String, - icon: Option[SvgElement] = None - )(mods: Modifier[HtmlElement]*): HtmlElement = - button( - tpe("button"), - cls( - "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - icon, - text, - mods - ) - - override def inlineButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", - srHelp(id), - icon - ) - - override def iconButton(id: String, icon: SvgElement)( - mods: Modifier[HtmlElement]* - ): HtmlElement = - button( - tpe := "button", - cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", - icon, - srHelp(id) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala new file mode 100644 index 0000000..3272cad --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ComputableComponent.scala @@ -0,0 +1,26 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.model.Computable +import works.iterative.ui.model.Computable.* +import com.raquo.laminar.tags.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.CommentNode +import org.scalajs.dom + +class ComputableComponent[Ref <: dom.html.Element]( + as: HtmlTag[Ref], + mods: Mod[ReactiveHtmlElement[Ref]]* +)( + c: Signal[Computable[HtmlElement]] +): + val element: ReactiveHtmlElement[Ref] = as( + mods, + child <-- c.map { + case Uninitialized => CommentNode("Uninitialized") + case Computing(_) => CommentNode("Computing") + case Ready(element) => element + case Failed(_) => CommentNode("Failed") + case Recomputing(_, element) => element + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala new file mode 100644 index 0000000..257391a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/CustomAttrs.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.codecs.{StringAsIsCodec, BooleanAsTrueFalseStringCodec} + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = htmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = htmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = htmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + svgAttr("aria-hidden", BooleanAsTrueFalseStringCodec, None) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala deleted file mode 100644 index 480f5a9..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala +++ /dev/null @@ -1,191 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import works.iterative.ui.model.color.ColorDef -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom.html.UList -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind - -trait DashboardComponentsModule: - def dashboard: DashboardComponents - - // Only methods that create HtmlElements - trait DashboardComponents(using ComponentContext): - def section(label: String, children: Modifier[HtmlElement]): Div - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li - def cardInitials( - initials: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div - def count(label: String, value: Int, color: ColorDef): Span - def counts(children: HtmlElement*): Span - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] - -trait DefaultDashboardComponentsModule(using ComponentContext) - extends DashboardComponentsModule: - override val dashboard: DashboardComponents = new DashboardComponents: - def section(label: String, children: Modifier[HtmlElement]): Div = - div( - cls("mt-3"), - h2( - cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), - label - ), - children - ) - - def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = - ul( - cls( - "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - ), - children - ) - - def card( - id: String, - label: String, - initials: Div, - children: Modifier[HtmlElement] - ): Li = - li( - div( - cls("col-span-1 flex shadow-sm rounded-md"), - initials, - div( - cls( - "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate" - ), - div( - cls("flex-1 px-4 py-2 text-sm truncate"), - div( - cls("text-gray-900 font-medium hover:text-gray-600 truncate"), - label - ), - p(cls("text-gray-500"), children) - ) - ) - ) - ) - - def cardInitials( - id: String, - color: Signal[ColorKind], - children: Modifier[HtmlElement] - ): Div = - div( - cls( - "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" - ), - color.map(_(600).bg), - div(id), - children - ) - - def count(label: String, value: Int, color: ColorDef): Span = - span(color.text, title(label), s"${value}") - - def counts(children: HtmlElement*): Span = - span(interleave(children, span(" / "))) - - def table( - headers: List[String], - sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] - ): Div = - div( - cls("flex flex-col mt-2"), - div( - cls( - "align-middle min-w-full shadow sm:rounded-lg" - ), - L.table( - cls("min-w-full border-separate border-spacing-0"), - styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work - thead( - tr( - cls("border-t border-gray-200"), - headers.map(h => - th( - cls( - "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - h - ) - ) - ) - ), - tbody(cls("bg-white divide-y divide-gray-100"), sections) - ) - ) - ) - - def tableSectionHeader( - label: Modifier[HtmlElement], - cs: Int, - mods: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - td( - cls("bg-gray-50 border-gray-200 border-t border-b"), - cls( - "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" - ), - colSpan(cs), - label - ), - mods - ) - - def tableRow( - label: String, - mods: Modifier[HtmlElement], - children: HtmlElement* - ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = - tr( - mods, - td( - cls( - "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" - ), - label - ), - children.map(e => - td( - cls( - "whitespace-nowrap px-2 py-2 text-sm text-gray-500" - ), - e - ) - ) - ) - - private def interleave( - a: Seq[HtmlElement], - separator: => HtmlElement - ): Seq[HtmlElement] = - if a.size < 2 then a - else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala new file mode 100644 index 0000000..519ab18 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponents.scala @@ -0,0 +1,63 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext + +trait FormComponents(using ctx: ComponentContext[_]) + extends LocalDateSelectModule: + def searchIcon: SvgElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + nameAttr(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) + + def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label(ctx.messages("forms.search.label"), forId(id), cls("sr-only")), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + searchIcon + ), + input( + tpe := "search", + nameAttr := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala deleted file mode 100644 index 66094fd..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ /dev/null @@ -1,173 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html -import com.raquo.laminar.modifiers.KeyUpdater - -trait FormComponentsModule extends LocalDateSelectModule: - def forms: FormComponents - - trait FormComponents: - def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field(label: Modifier[HtmlElement])( - content: Modifier[HtmlElement]* - ): HtmlElement - - def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement - - def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( - actions: Modifier[HtmlElement]* - ): HtmlElement - - def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement - - def searchField(id: String, placeholderText: Option[String] = None)( - mods: Modifier[HtmlElement]* - ): HtmlElement - - def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement - -trait DefaultFormComponentsModule(using ctx: ComponentContext) - extends FormComponentsModule: - self: IconsModule => - override val forms = new FormComponents: - - override def section( - title: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] - )(content: Modifier[HtmlElement]*): HtmlElement = - div( - cls("space-y-6 sm:space-y-5"), - div( - h3(cls("text-lg leading-6 font-medium text-gray-900"), title), - subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) - ), - div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) - ) - - override def field( - label: Modifier[HtmlElement] - )(content: Modifier[HtmlElement]*): 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, - div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) - ) - - override def field( - id: String, - label: String, - input: HtmlElement, - help: Option[String] - ): HtmlElement = - field( - L.label( - cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), - forId(id), - label - ) - )( - input.amend(idAttr(id)), - help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) - ) - - override def form(mods: Modifier[HtmlElement]*)( - sections: Modifier[HtmlElement]* - )(actions: Modifier[HtmlElement]*): HtmlElement = - L.form( - cls("space-y-8 divide-y divide-gray-200"), - mods, - sections, - div( - cls("pt-5"), - div(cls("flex justify-end"), actions) - ) - ) - - override def inlineForm( - mods: Modifier[HtmlElement]* - ): HtmlElement = - L.form(cls("flex space-x-4"), mods) - - override def searchField( - id: String, - placeholderText: Option[String] = None - )( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "flex-1 min-w-0", - label( - forId := id, - cls := "sr-only", - "Hledat" - ), - div( - cls := "relative rounded-md shadow-sm", - div( - cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", - icons.`search-solid`() - ), - input( - tpe := "search", - name := "search", - idAttr := id, - cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", - placeholderText - .orElse( - ctx.messages - .opt( - s"forms.search.${id}.placeholder", - s"form.search.placeholder" - ) - ) - .map(placeholder(_)), - mods - ) - ) - ) - - override def renderLocalDateSelect( - id: String, - labelText: Option[String], - placeholderText: Option[String], - mods: LocalDateSelect => Modifier[HtmlElement] - ): HtmlElement = - div( - labelText, - input( - idAttr(id), - name(id), - autoComplete("date"), - tpe("date"), - cls( - "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" - ), - placeholderText.map(placeholder(_)), - mods(localDateSelect) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala new file mode 100644 index 0000000..6596de1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HasEffectHook.scala @@ -0,0 +1,21 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +/** A way to add effect processor to component. + * + * To ensure that the lifecycle of the effect processor is bound to the + * lifecycle of the component, the bind is done in the component itself. + * + * There is a default implementation for components that return HtmlElement. If + * the component uses different type, it needs to provide its own + * implementation. + */ +trait HasEffectHook[O]: + extension (o: O) def amend(mod: HtmlMod): O + +object HasEffectHook: + given HasEffectHook[HtmlElement] with + extension (e: HtmlElement) + def amend(mod: HtmlMod): HtmlElement = + e.amend(mod) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala new file mode 100644 index 0000000..01ba363 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlRenderable.scala @@ -0,0 +1,39 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant +import java.time.format.DateTimeFormatter +import works.iterative.ui.TimeUtils + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Modifier[HtmlElement] = a + + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDate(v)), + TimeUtils.formatDate(v) + ) + + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + timeTag( + CustomAttrs.datetime(TimeUtils.formatHtmlDateTime(v)), + TimeUtils.formatDateTime(v) + ) + + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p(cls("whitespace-pre-wrap"), v.toString) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala deleted file mode 100644 index 32fda8a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala +++ /dev/null @@ -1,109 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.CustomAttrs - -trait IconsModule: - def icons: Icons - - trait Icons: - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement - def close(mods: Modifier[SvgElement]*): SvgElement - def `search-solid`(mods: Modifier[SvgElement]*): SvgElement - def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement - def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement - -trait DefaultIconsModule(using ComponentContext) extends IconsModule: - override val icons: Icons = new Icons: - import svg.* - - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - private def withDefault( - mods: Seq[Modifier[SvgElement]], - default: Modifier[SvgElement] - ): Modifier[SvgElement] = - if mods.isEmpty then default else mods - - def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-8 w-8"), - 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" - ) - ) - ) - - override def close(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls := "h-2 w-2"), - stroke := "currentColor", - fill := "none", - viewBox := "0 0 8 8", - path( - strokeLineCap := "round", - strokeWidth := "1.5", - d := "M1 1l6 6m0-6L1 7" - ) - ) - - override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 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 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") - ) - ) - - override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = - svg( - withDefault(mods, cls("h-5 w-5 text-gray-400")), - 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") - ) - ) - - override def `document-chart-bar-outline`( - mods: Modifier[SvgElement]* - ): SvgElement = - svg( - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - strokeWidth := "1.5", - stroke := "currentColor", - withDefault(mods, cls := "h-6 w-6"), - path( - strokeLineCap := "round", - strokeLineJoin := "round", - d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala index f7bacb1..04e76e7 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -3,13 +3,17 @@ import com.raquo.laminar.api.L.{*, given} -abstract class LaminarComponent[M, A, E]( +/** Hook the action -> effect cycle into a component. + */ +abstract class LaminarComponent[M, A, +E, +O: HasEffectHook]( effectHandler: EffectHandler[E, A] ) extends Module[M, A, E]: - def render(m: Signal[M], actions: Observer[A]): HtmlElement + def render(m: Signal[M], actions: Observer[A]): O - val element: HtmlElement = - val actions = new EventBus[A] + /** Event bus for this component's actions. */ + val actions = new EventBus[A] + + val view: O = val zero @ (_, effect) = init @@ -20,7 +24,7 @@ val actions$ = actions.events.recover(handleFailure) - val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + val processor$ = actions$.scanLeft(zero) { case ((m, _), a) => handle(a, m) } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..4982296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarExtensions.scala @@ -0,0 +1,96 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.UserMessage +import io.laminext.syntax.core.* +import works.iterative.core.MessageId +import com.raquo.laminar.modifiers.RenderableNode +import com.raquo.laminar.nodes.ChildNode.Base +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.HtmlRenderable +import com.raquo.airstream.custom.CustomStreamSource +import com.raquo.airstream.custom.CustomSource +import zio.IsSubtypeOfError + +object LaminarExtensions extends I18NExtensions with ZIOInteropExtensions + +trait I18NExtensions: + extension (msg: UserMessage) + inline def asElement(using ctx: ComponentContext[?]): HtmlElement = + span(msg.asMod) + + inline def asOptionalElement(using + ctx: ComponentContext[?] + ): Option[HtmlElement] = + ctx.messages.get(msg).map(t => span(msgAttrs(msg.id, t))) + + inline def asString(using ctx: ComponentContext[?]): String = + ctx.messages(msg) + + inline def asOptionalString(using + ctx: ComponentContext[?] + ): Option[String] = + ctx.messages.get(msg) + + inline def asMod(using ctx: ComponentContext[?]): Mod[HtmlElement] = + msgAttrs(msg.id, ctx.messages(msg)) + + private inline def msgAttrs(id: MessageId, text: String): HtmlMod = + nodeSeq(dataAttr("msgid")(id.toString()), text) + + given (using ComponentContext[?]): HtmlRenderable[UserMessage] with + def toHtml(msg: UserMessage): Modifier[HtmlElement] = + msg.asElement + +trait ZIOInteropExtensions: + import zio.{ZIO, Runtime, Unsafe, Fiber, Cause} + + extension [R, E, O](effect: ZIO[R, E, O]) + def toEventStream(using runtime: Runtime[R])(using + ev: E IsSubtypeOfError Throwable + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit(cause => fireError(cause.squash), fireValue) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) + + def toEventStreamWith(mapError: E => Throwable)(using + runtime: Runtime[R] + ): EventStream[O] = + var fiberRuntime: Fiber.Runtime[E, O] = null + EventStream.fromCustomSource( + shouldStart = _ == 1, + start = (fireValue, fireError, getStartIndex, getIsStarted) => + Unsafe.unsafe { implicit unsafe => + fiberRuntime = runtime.unsafe.fork(effect) + fiberRuntime.unsafe.addObserver(exit => + exit.foldExit( + cause => fireError(cause.squashWith(mapError)), + fireValue + ) + ) + fiberRuntime = null + }, + stop = _ => + if fiberRuntime != null then + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(fiberRuntime.interrupt).ignore + } + fiberRuntime = null + else () + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala deleted file mode 100644 index 5de4d67..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala +++ /dev/null @@ -1,114 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.model.color.ColorKind -import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html.Paragraph -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.laminar.nodes.TextNode - -trait ListComponentsModule: - - def list: ListComponents - - trait ListComponents(using ComponentContext): - def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] - def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li - def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] - def listSection(header: String, list: HtmlElement): Div - def navigation(sections: Modifier[HtmlElement]): HtmlElement - -trait DefaultListComponentsModule(using ComponentContext) - extends ListComponentsModule: - override val list: ListComponents = new ListComponents: - override def label( - text: String, - color: ColorKind - ): ReactiveHtmlElement[Paragraph] = - p( - cls( - "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" - ), - color(800).text, - color(100).bg, - text - ) - - override def item( - title: String, - subtitle: Option[String], - right: Modifier[HtmlElement] = emptyMod, - avatar: Option[Modifier[HtmlElement]] = None - ): Li = - li( - cls("group"), - div( - cls( - "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" - ), - avatar.map(a => - div( - cls("flex-shrink-0"), - div( - cls( - "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" - ), - a - ) - ) - ), - div( - cls("flex-1 min-w-0"), - p( - cls("text-sm font-medium text-gray-900"), - title, - span(cls("float-right"), right) - ), - subtitle.map(st => - p( - cls("text-sm text-gray-500 truncate"), - st - ) - ) - ) - ) - ) - - override def unordered( - children: Modifier[HtmlElement] - ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = - ul( - cls("relative z-0 divide-y divide-gray-200"), - role("list"), - children - ) - - override def listSection( - header: String, - list: HtmlElement - ): Div = - div( - cls("relative"), - div( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - list - ) - - override def navigation(sections: Modifier[HtmlElement]): HtmlElement = - nav( - cls("flex-1 min-h-0 overflow-y-auto"), - sections - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala index 31d860d..26a7e77 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -2,12 +2,11 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext + import java.time.LocalDate import java.time.format.DateTimeFormatter -import com.raquo.laminar.keys.ReactiveProp -import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent -import org.scalajs.dom.html +import org.scalajs.dom.{Event, html} import com.raquo.laminar.modifiers.KeyUpdater trait LocalDateSelectModule: @@ -23,28 +22,27 @@ // Does not work in `controlled` // Laminar refuses the custom prop, requries its own `value` or `checked` - val value: ReactiveProp[Option[LocalDate], String] = - customProp("value", OptLocalDateAsStringCodec) + val value: HtmlProp[Option[LocalDate]] = + htmlProp("value", OptLocalDateAsStringCodec) - val min: ReactiveProp[LocalDate, String] = - customProp("min", LocalDateAsStringCodec) + val min: HtmlProp[LocalDate] = + htmlProp("min", LocalDateAsStringCodec) - val max: ReactiveProp[LocalDate, String] = - customProp("max", LocalDateAsStringCodec) + val max: HtmlProp[LocalDate] = + htmlProp("max", LocalDateAsStringCodec) - val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + val onInput: EventProcessor[Event, LocalDate] = L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => d } - val onOptInput - : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + val onOptInput: EventProcessor[Event, Option[LocalDate]] = onInput.mapToValue.setAsValue.map(parseDate) object LocalDateSelect: import java.time.format.DateTimeFormatter import java.time.LocalDate - import com.raquo.domtypes.generic.codecs.Codec + import com.raquo.laminar.codecs.Codec private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala deleted file mode 100644 index 3f3e7d0..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.ui.components.laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.ComponentContext - -trait PageComponentsModule: - - val page: PageComponents - - trait PageComponents: - def container( - children: Modifier[HtmlElement]* - ): HtmlElement - - def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement - - def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement] = emptyMod, - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement - - /** Visage for clickable text, like links - */ - def clickable: Modifier[HtmlElement] - -trait DefaultPageComponentsModule(using ComponentContext) - extends PageComponentsModule: - - override val page: PageComponents = new PageComponents: - override def container( - children: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), - children - ) - - override def singleColumn( - header: Modifier[HtmlElement] - )(children: Modifier[HtmlElement]*): HtmlElement = - div( - cls("p-8 bg-gray-100 h-full"), - header, - children - ) - - override def pageHeader( - title: Modifier[HtmlElement], - right: Modifier[HtmlElement], - subtitle: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - div( - cls("pb-5 border-b border-gray-200"), - div(cls("float-right"), right), - h1( - cls("text-2xl leading-6 font-medium text-gray-900"), - title - ), - subtitle.map( - p( - cls("text-sm font-medium text-gray-500"), - _ - ) - ) - ) - - override def clickable: Modifier[HtmlElement] = - cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala new file mode 100644 index 0000000..6d66554 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala new file mode 100644 index 0000000..d331978 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,291 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport + +case class ChoiceOption[A](id: String, label: String, value: A) + +case class Choice[A]( + options: List[ChoiceOption[A]] +) + +trait FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) + + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + given choiceInput[A](using Choice[A], FormBuilderContext): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val options = summon[Choice[A]].options + ChoiceField( + fieldDescriptor, + Some(initialValue.getOrElse(options.head.value)), + Validations.requiredA(fieldDescriptor.label)(_), + options + ) + + given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using + ev: B <:< Option[A] + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + ChoiceField( + fieldDescriptor, + initialValue, + a => Validation.succeed(a.flatten), + ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + o.copy(value = Some(o.value)) + ) + ) + + class InputField[A]( + desc: FieldDescriptor, + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var(initialValue) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderInputField( + desc, + initialValue, + validated, + rawValue.writer + ) + + class FileField[A]( + desc: FieldDescriptor, + validation: Option[FileList] => Validated[A] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) + + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) + + override val elements: Seq[HtmlElement] = + renderFileInputField(desc, rawValue.writer.contramapSome) + + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = + + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + class ChoiceField[A]( + desc: FieldDescriptor, + initialValue: Option[A], + validation: Option[A] => Validated[A], + options: List[ChoiceOption[A]] + )(using fctx: FormBuilderContext) + extends FormComponent[A]: + private val rawValue: Var[Option[String]] = Var( + initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + ) + + override val validated: Signal[Validated[A]] = + rawValue.signal + .map(_.flatMap(i => options.find(_.id == i).map(_.value))) + .map(validation) + + override val elements: Seq[HtmlElement] = + renderSelect( + desc, + initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + options.map(o => (o.id, o.label)), + rawValue.writer.contramapSome + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.placeholder.getOrElse(desc.label))()( + multiple(false), + nameAttr(desc.name), + idAttr(desc.idString), + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) + + def renderSelect( + desc: FieldDescriptor, + initialValue: Option[String], + options: List[(String, String)], + observer: Observer[String] + )(using + fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + select( + idAttr(desc.idString), + nameAttr(desc.name), + cls( + "mt-2 block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + initialValue.map(L.value(_)), + options.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..787ff7b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[String] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[String] = + ctx.messages.get(fieldId + ".placeholder") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala new file mode 100644 index 0000000..3f13f01 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition + +sealed trait Form[A] extends FormBuilder[A] + +object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using + fctx: FormBuilderContext + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + val field = summon[FieldBuilder[A]] + field + .build(desc, initialValue) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + + extension [A](f: Form[A]) + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala new file mode 100644 index 0000000..218674f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -0,0 +1,36 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +trait FormBuilderModule: + def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = + HtmlFormBuilder[A](form, submit) + + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = + val f = form.build(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala new file mode 100644 index 0000000..cdb24d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import com.raquo.airstream.core.Signal +import works.iterative.core.Validated +import app.tulz.tuplez.Composition + +trait FormComponent[A]: + def validated: Signal[Validated[A]] + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + FormComponent(fc.validated, Seq(wrapper(fc.elements))) + def map[B](f: A => B): FormComponent[B] = + FormComponent(fc.validated.map(_.map(f)), fc.elements) + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = + FormComponent( + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala new file mode 100644 index 0000000..d4c21cf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormMessagesResolver.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage + +trait FormMessagesResolver: + def label(name: String): String + def help(name: String): Option[String] + def placeholder(name: String): Option[String] + def message(msg: UserMessage): String + +object FormMessagesResolver: + given (using ctx: ComponentContext[_]): FormMessagesResolver = + MessageCatalogueFormMessagesResolver(using ctx.messages) + + given (using cat: MessageCatalogue): FormMessagesResolver = + MessageCatalogueFormMessagesResolver() + +class MessageCatalogueFormMessagesResolver(using cat: MessageCatalogue) + extends FormMessagesResolver: + override def label(name: String): String = cat(name) + override def help(name: String): Option[String] = cat.get(s"$name.help") + override def placeholder(name: String): Option[String] = + cat.get(s"$name.placeholder") + + override def message(msg: UserMessage): String = cat(msg) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala new file mode 100644 index 0000000..7757539 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait FormUIFactory: + def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] + + def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): HtmlElement + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] + + def field(label: HtmlMod)(content: HtmlMod*): HtmlElement + + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement + + def validationError(text: HtmlMod): HtmlElement + + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom.html +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.core.UserMessage +import works.iterative.core.PlainMultiLine +import com.raquo.airstream.core.Signal +import works.iterative.ui.components.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..44a50a6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..67410cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) + + def requiredA[A](label: String): Option[A] => Validated[A] = + case Some(value) => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..466a79c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,8 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..8e97f7d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.core.UserMessage +import works.iterative.ui.components.laminar.LaminarExtensions.given +import works.iterative.ui.components.ComponentContext +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableUIFactory: + def container(table: ReactiveHtmlElement[html.Table]): HtmlElement + def table(headerRows: ReactiveHtmlElement[html.TableRow]*)( + bodyRows: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] + def headerRow(mod: HtmlMod)( + headerCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def dataRow(mod: HtmlMod)( + dataCells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] + +trait HtmlTableBuilderModule: + def tableHeaderResolver: TableHeaderResolver + def tableUIFactory: TableUIFactory + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + HtmlTableBuilder[A](data) + + case class HtmlTableBuilder[A]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + )(using tab: HtmlTabular[A]): + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = (a, _) => mod(a)) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def buildTableHeader: ReactiveHtmlElement[html.TableRow] = + tableUIFactory.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tableUIFactory.headerCell( + Seq[HtmlMod](headerCellMod(n), tableHeaderResolver(n)) + ) + }* + ) + + def buildTableData( + data: List[A] + ): List[ReactiveHtmlElement[html.TableRow]] = + data.zipWithIndex.map((d, idx) => + tableUIFactory.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tableUIFactory.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + + def build: HtmlElement = + tableUIFactory.container( + tableUIFactory.table(buildTableHeader)(buildTableData(data)*) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..782b912 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext[_]): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala new file mode 100644 index 0000000..87f3e8d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/Color.scala @@ -0,0 +1,54 @@ +package works.iterative.ui.components.laminar.tailwind.color + +import com.raquo.laminar.api.L.{*, given} + +/** Complete color definition that can be rendered to CSS. + * + * Includes the area, kind and weight of the color. + */ +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + given colorToCSS: Conversion[Color, HtmlMod] with + def apply(c: Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[Color, SvgMod] with + def apply(c: Color) = svg.cls(c.toCSS) + + given colorSignalToCSS: Conversion[Signal[Color], HtmlMod] with + def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS: Conversion[Signal[Color], SvgMod] with + def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala new file mode 100644 index 0000000..4130840 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorArea.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines the area the color should apply to, eg. background, text, border, + * etc. + */ +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala new file mode 100644 index 0000000..fab9404 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorDef.scala @@ -0,0 +1,43 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** A combination of ColorKind and ColorWeight, if applicable. + * + * By applying area we get the full Color definition. + */ +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala new file mode 100644 index 0000000..865166b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorKind.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.laminar.tailwind.color + +/** Defines what color should be used, without specifying the area or weight. + */ +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala new file mode 100644 index 0000000..6f1a4cc --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/ColorWeight.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.color + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +/** Defines weight of a color, eg. 50, 100, 200, etc. + * + * Tailwind-like. + */ +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala new file mode 100644 index 0000000..21e21f0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/color/package.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.tailwind + +/** We need a generic Color model that can be used both on server and on client. + * + * We have adopted the Tailwind model for now. There is nothing inherently + * Tailwind-specific in the implementation, but all the values are taken from + * their palette and the area model is very HTML biased. + * + * Still, I think that it is a good starting point for exploration and will + * satisfy our current needs. + */ +package object color {} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala index 637c787..305f467 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -1,8 +1,9 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import CustomAttrs.ariaHidden +import laminar.CustomAttrs.ariaHidden import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec // TODO: macros for size class Avatar($avatarImg: Signal[Option[String]]): diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala index a2f8519..796c251 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -1,22 +1,20 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.codecs.BooleanAsTrueFalseStringCodec import com.raquo.laminar.api.L.svg.{*, given} import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: - val hidden = CustomAttrs.svg.ariaHidden + val hidden = laminar.CustomAttrs.svg.ariaHidden inline def spinner(extraClasses: String): SvgElement = svg( cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", + svgAttr("role", StringAsIsCodec, None) := "status", cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", viewBox := "0 0 100 101", fill := "none", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala index b662394..5f17be5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -1,8 +1,11 @@ package works.iterative -package ui.components.tailwind +package ui.components +package tailwind import com.raquo.laminar.api.L.{*, given} object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) + def card(content: Modifier[HtmlElement]*)(using + cctx: ComponentContext[_] + ): Div = + div(cls("bg-white shadow sm:rounded-md overflow-hidden"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -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/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index bc25bf5..3dd5134 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -33,7 +33,7 @@ ) div( - cls("fixed inset-0 z-20 overflow-y-auto"), + cls("fixed inset-0 z-50 overflow-y-auto"), div( cls("text-center sm:block sm:p-0"), overlay, diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 3b1128d..b5af03c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -2,36 +2,37 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.LocalDate import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.laminar.HtmlRenderable import works.iterative.ui.components.tailwind.form.ActionButtons import works.iterative.ui.components.tailwind.HtmlComponent import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext import works.iterative.ui.components.tailwind.Icons +import scala.reflect.ClassTag -type ValueContent = String | Node +type ValueContent = String | Modifier[HtmlElement] type OptionalValueContent = ValueContent | Option[ValueContent] case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None + def content: Option[Modifier[HtmlElement]] = body match + case Some(s: String) => Some(s) + case Some(m: Modifier[HtmlElement]) => Some(m) + case s: String => Some(s) + case m: Modifier[_] => Some(m.asInstanceOf[Modifier[HtmlElement]]) + case _ => None object LabeledValue: given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, V), LabeledValue] with def apply(v: (String, V)) = LabeledValue(cctx.messages(v._1), Some(v._2.render)) given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext + cctx: ComponentContext[_] ): Conversion[(String, Option[V]), LabeledValue] with def apply(v: (String, Option[V])) = LabeledValue(cctx.messages(v._1), v._2.map(_.render)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala index e02fe4d..f5ac8cb 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -4,7 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext case class ActionButtonStyle( border: String, @@ -29,7 +29,9 @@ action: A, style: ActionButtonStyle = ActionButtonStyle.default ): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + def element(actions: Observer[A])(using + ctx: ComponentContext[_] + ): HtmlElement = button( tpe("button"), cls("first:ml-0 ml-3"), @@ -47,7 +49,7 @@ case class ActionButtons[A](actions: List[ActionButton[A]]) object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) + class Component[A](actions: Observer[A])(using ctx: ComponentContext[_]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: override def render(v: ActionButtons[A]) = div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala index 8c5b8a1..d9ee9aa 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -1,4 +1,5 @@ -package works.iterative.ui.components.tailwind +package works.iterative.ui.components +package tailwind package form import com.raquo.laminar.api.L.{*, given} @@ -35,7 +36,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", @@ -79,7 +80,7 @@ xmlns := "http://www.w3.org/2000/svg", viewBox := "0 0 20 20", fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, + laminar.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", diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala index 7d21f70..587a35a 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -5,7 +5,7 @@ import com.raquo.laminar.api.L.{*, given} import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext trait FormInput[V]: def render( @@ -25,5 +25,7 @@ TextArea() given optionLocalDateInput: FormInput[Option[LocalDate]] = Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + given optionBooleanInput(using + ComponentContext[_] + ): FormInput[Option[Boolean]] = Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala index e1016e2..976cbd3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -16,7 +16,7 @@ )(using codec: FormCodec[V, String]): Input = input( idAttr := prop.id, - name := prop.name, + nameAttr := prop.name, tpe := inputType, cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", prop.value.map(v => value(codec.toForm(v))), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala index a8c3d81..73ce0a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -3,9 +3,9 @@ import com.raquo.laminar.api.L.{*, given} import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext[_]) extends FormInput[V]: def render( property: Property[V], @@ -32,7 +32,7 @@ if v then "translate-x-5" else "translate-x-0" ) ), - composeEvents(onClick)( + onClick.compose( _.sample(currentValue.signal).map(v => !v) ) --> currentValue ), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala index d41b4ab..5bb8f69 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -32,7 +32,7 @@ textArea( changeBus.events.map(numberOfLines) --> rowNo, changeBus.events --> updates, - name := fieldName, + nameAttr := fieldName, rows <-- rowNo.signal.map(_ + 2), mods, currentValue.map(value(_)), diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala deleted file mode 100644 index 3899808..0000000 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package laminar - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.model.color.Color - -object LaminarExtensions: - given colorToCSS: Conversion[Color, Modifier[HtmlElement]] with - def apply(c: Color) = cls(c.toCSS) - - given colorToSVGCSS: Conversion[Color, Modifier[SvgElement]] with - def apply(c: Color) = svg.cls(c.toCSS) - - given colorSignalToCSS: Conversion[Signal[Color], Modifier[HtmlElement]] with - def apply(c: Signal[Color]) = cls <-- c.map(_.toCSS) - - given colorSignalToSVGCSS: Conversion[Signal[Color], Modifier[SvgElement]] - with - def apply(c: Signal[Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala index 9f5a7b1..248c457 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -2,8 +2,8 @@ package list import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.tags.HtmlTag import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag object IconText: case class ViewModel(text: HtmlElement, icon: SvgElement) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala index 54d74f1..197ea5d 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -3,7 +3,6 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag import com.raquo.laminar.nodes.ReactiveHtmlElement trait AsListRow[A]: diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala index 0b7841b..2458be4 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -3,17 +3,17 @@ import com.raquo.laminar.api.L.{*, given} import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.ui.TimeUtils import java.time.temporal.TemporalAccessor import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.codecs.StringAsIsCodec import java.time.format.DateTimeFormatter import java.time.ZoneId object SimpleWithIcons: def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( + timeTag( + htmlAttr( "datetime", StringAsIsCodec ) := DateTimeFormatter.ISO_LOCAL_DATE @@ -28,19 +28,17 @@ date: HtmlElement, last: Boolean ): HtmlElement = + val lastDivider: Modifier[HtmlElement] = + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) li( div( cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, + if !last then lastDivider else emptyMod, div( cls("relative flex space-x-3"), div( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala index 5918215..a6f4022 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -3,13 +3,13 @@ import com.raquo.laminar.api.L.{*, given} import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.ComponentContext object Tabs: def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( updates: Observer[T] )(using - ctx: ComponentContext + ctx: ComponentContext[_] ): HtmlElement = val m = tabs .map { case (t, v) => @@ -24,7 +24,7 @@ label(forId := "tabs", cls := "sr-only", "Select a tab"), select( idAttr := "tabs", - name := "tabs", + nameAttr := "tabs", cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", tabs.map { case (t, _) => option( @@ -40,7 +40,7 @@ cls := "hidden sm:block", div( cls := "border-b border-gray-200", - nav( + navTag( cls := "-mb-px flex space-x-8", aria.label := "Tabs", tabs.map { case (t, v) => diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala index 99a212a..ff4203e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -11,10 +11,7 @@ import scala.scalajs.js import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide -import works.iterative.ui.model.color.Color -import works.iterative.ui.model.color.ColorWeight +import works.iterative.ui.components.ComponentContext object Scenario: type Id = String @@ -27,30 +24,30 @@ def label: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement trait ScenarioExample: def title: String - def element(using ComponentContext): HtmlElement + def element(using ComponentContext[_]): HtmlElement object ScenarioExample: def apply( t: String, - elem: ComponentContext ?=> HtmlElement + elem: ComponentContext[_] ?=> HtmlElement ): ScenarioExample = new ScenarioExample: override val title: String = t - override def element(using ComponentContext): HtmlElement = elem + override def element(using ComponentContext[_]): HtmlElement = elem trait ScenarioExamples: self: Scenario => protected def examples(using ScenarioContext, - ComponentContext + ComponentContext[_] ): List[ScenarioExample] - override def element(using ComponentContext): HtmlElement = + override def element(using ComponentContext[_]): HtmlElement = val eventBus: EventBus[Any] = EventBus[Any]() given sc: ScenarioContext = new ScenarioContext: diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 90e446c..cc15f90 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -7,8 +7,7 @@ import scala.scalajs.js import works.iterative.ui.JsonMessageCatalogue import works.iterative.core.MessageCatalogue -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.ComponentContext import ui.components.tailwind.TailwindSupport import com.raquo.waypoint.* @@ -42,16 +41,16 @@ identity[String], routeFallback = _ => scenarios.head.id )( - windowEvents.onPopState, + windowEvents(_.onPopState), unsafeWindowOwner ) def main(args: Array[String]): Unit = given MessageCatalogue = messageCatalogue - given ComponentContext with + given ComponentContext[Unit] with + val app: Unit = () val messages: MessageCatalogue = messageCatalogue - val style: StyleGuide = StyleGuide.default def container: HtmlElement = div( @@ -64,12 +63,12 @@ cls( "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" ), - nav( + navTag( cls("flex flex-1 flex-col"), ul( role("list"), cls("flex flex-1 flex-col gap-y-7"), - children <-- router.$currentPage.map(id => + children <-- router.currentPageSignal.map(id => scenarios.map(s => li( a( @@ -92,13 +91,13 @@ ) ) ), - com.raquo.laminar.api.L.main( + mainTag( cls("h-full pl-72"), div( cls( "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" ), - child <-- router.$currentPage.map(scenarioMap(_).element) + child <-- router.currentPageSignal.map(scenarioMap(_).element) ) ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala new file mode 100644 index 0000000..25b57d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/services/ConsoleNotificationService.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.services + +import zio.* +import works.iterative.core.UserMessage + +class ConsoleNotificationService extends UserNotificationService: + override def notify( + level: UserNotificationService.Level, + msg: UserMessage + ): UIO[Unit] = + ZIO.succeed(org.scalajs.dom.console.log(s"[$level] $msg")) + +object ConsoleNotificationService: + val layer: ULayer[UserNotificationService] = + ZLayer.succeed(ConsoleNotificationService()) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala new file mode 100644 index 0000000..8bde2d2 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/ZIOLaminarInteropSpec.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar + +import zio.* +import zio.test.* +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Observer +import com.raquo.airstream.ownership.Owner + +object ZIOLaminarInteropSpec extends ZIOSpecDefault: + override def spec: Spec[TestEnvironment & Scope, Any] = + suite("ZIO-Laminar interop should")( + test("run a ZIO effect to EventStream") { + import LaminarExtensions.* + + given runtime: Runtime[Any] = Runtime.default + given owner: Owner = new Owner { + def killAll(): Unit = this.killSubscriptions() + } + val ev: EventStream[String] = ZIO.succeed("Hello").toEventStream + val buffer = collection.mutable.Buffer[String]() + val subscription = ev.foreach(buffer += _) + subscription.kill() + assertTrue(buffer.size == 1, buffer.head == "Hello") + } + ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + ) diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala index 6a44b4a..574b355 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/Module.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -1,10 +1,11 @@ package works.iterative.ui -trait Module[Model, Action, Effect]: +trait Module[Model, Action, +Effect]: // Define initial model and effect def init: (Model, Option[Effect]) // Define how to handle actions to build new model and run effects def handle(action: Action, model: Model): (Model, Option[Effect]) // Optionally define how to handle failures. // To be used by implementations to allow module to display error messages. - def handleFailure: PartialFunction[Throwable, Option[Action]] + def handleFailure: PartialFunction[Throwable, Option[Action]] = + PartialFunction.empty diff --git a/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala new file mode 100644 index 0000000..112786c --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/TimeUtils.scala @@ -0,0 +1,44 @@ +package works.iterative.ui + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.systemDefault()) + + val htmlDateFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd") + .withZone(ZoneId.systemDefault()) + + val htmlDateTimeFormat = + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) + + def formatHtmlDate(i: TemporalAccessor): String = + htmlDateFormat.format(i) + + def formatHtmlDateTime(i: TemporalAccessor): String = + htmlDateTimeFormat.format(i) diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala index 875c7a8..a3ea176 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -1,6 +1,22 @@ package works.iterative.ui +import zio.* import zio.stream.* trait ZIOEffectHandler[Env, Effect, Action]: def handle(e: Effect): ZStream[Env, Throwable, Action] + + def fromZIO( + zio: ZIO[Env, Throwable, Action] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio) + + def fromZIOOption( + zio: ZIO[Env, Throwable, Option[Action]] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio).collect { case Some(a) => a } + + def fromZIOUnit( + zio: ZIO[Env, Throwable, Unit] + ): ZStream[Env, Throwable, Action] = + ZStream.fromZIO(zio.as(Option.empty[Action])).collect { case Some(a) => a } diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala index 29287ba..f888701 100644 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -1,12 +1,66 @@ package works.iterative.ui.model import works.iterative.core.UserMessage +import java.time.Instant +import zio.prelude.Covariant +import zio.prelude.ForEach +import zio.prelude.IdentityBoth /** A class representing the states of a model that needs computation */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] +sealed trait Computable[+Model]: + /** Update the computation state with new data + */ + def update[B >: Model](m: B): Computable[B] + + /** Mark the computation as started + */ + def started: Computable[Model] + +object Computable: + /** The initial state of a computable model + */ + case object Uninitialized extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + /** The computation is in progress + */ + case class Computing(start: Instant) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = this + + /** The computation is finished and the data is available + */ + case class Ready[Model](model: Model) extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = Recomputing(Instant.now(), model) + + /** The computation is finished and the data is available, but it is being + * recomputed + */ + case class Recomputing[Model](start: Instant, model: Model) + extends Computable[Model]: + override def update[B >: Model](m: B): Computable[B] = Ready(m) + override def started: Computable[Model] = this + + /** The computation failed + */ + case class Failed(error: UserMessage) extends Computable[Nothing]: + override def update[B](m: B): Computable[B] = Ready(m) + override def started: Computable[Nothing] = Computing(Instant.now()) + + given Covariant[Computable] with + def map[A, B](f: A => B): Computable[A] => Computable[B] = + _ match + case Uninitialized => Uninitialized + case Computing(start) => Computing(start) + case Ready(model) => Ready(f(model)) + case Failed(error) => Failed(error) + case Recomputing(start, model) => Recomputing(start, f(model)) + + extension [A](c: Computable[A]) + def toOption: Option[A] = c match + case Ready(model) => Some(model) + case Recomputing(start, model) => Some(model) + case _ => None diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala deleted file mode 100644 index 7352579..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/Tag.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.ui.model.color.ColorKind - -/** Representation of colored string value. - * - * Used generally to represent tags or "labels", eg. some kind of status or - * categorization. - */ -final case class Tag(value: String, color: ColorKind = ColorKind.gray) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala deleted file mode 100644 index 09d6e4d..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/Color.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.model.color - -/** Complete color definition that can be rendered to CSS. - * - * Includes the area, kind and weight of the color. - */ -case class Color(area: ColorArea, color: ColorDef): - def toCSS: String = s"${area.name}-${color.toCSS}" - -object Color: - import ColorDef.given - - def current = ColorDef(ColorKind.current) - def inherit = ColorDef(ColorKind.inherit) - def transp = ColorDef(ColorKind.transp) - def auto = ColorDef(ColorKind.auto) - def black = ColorDef(ColorKind.black) - def white = ColorDef(ColorKind.white) - def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) - def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) - def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) - def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) - def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) - def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) - def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) - def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) - def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) - def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) - def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) - def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) - def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) - def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) - def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) - def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) - def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) - def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) - def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) - def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) - def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) - def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala deleted file mode 100644 index 1211287..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorArea.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines the area the color should apply to, eg. background, text, border, - * etc. - */ -enum ColorArea(val name: String): - case bg extends ColorArea("bg") - case text extends ColorArea("text") - case decoration extends ColorArea("decoration") - case border extends ColorArea("border") - case outline extends ColorArea("outline") - case divide extends ColorArea("divide") - case ring extends ColorArea("ring") - case ringOffset extends ColorArea("ring-offset") - case shadow extends ColorArea("shadow") - case accent extends ColorArea("accent") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala deleted file mode 100644 index 9c5ec61..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorDef.scala +++ /dev/null @@ -1,43 +0,0 @@ -package works.iterative.ui.model.color - -/** A combination of ColorKind and ColorWeight, if applicable. - * - * By applying area we get the full Color definition. - */ -sealed trait ColorDef: - def toCSS: String - - def bg = Color(ColorArea.bg, this) - def text = Color(ColorArea.text, this) - def decoration = Color(ColorArea.decoration, this) - def border = Color(ColorArea.border, this) - def outline = Color(ColorArea.outline, this) - def divide = Color(ColorArea.divide, this) - def ring = Color(ColorArea.ring, this) - def ringOffset = Color(ColorArea.ringOffset, this) - def shadow = Color(ColorArea.shadow, this) - def accent = Color(ColorArea.accent, this) - -// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. -object ColorDef: - case class WeightedColorDef( - kind: ColorKind, - weight: ColorWeight - ) extends ColorDef: - override def toCSS: String = s"${kind.name}-${weight.value}" - - case class UnweightedColorDef( - kind: ColorKind - ) extends ColorDef: - override def toCSS: String = kind.name - - // TODO: check that the kind is valid unweighted kind - // that means current, inherit, auto, transparent, black, white - // tried using implicit evidence, but the type inference for enumerations - // tends to generalize to the enum, instead of the real type - def apply[T <: ColorKind](kind: T)(using - ev: T <:< ColorKind.Unweighted - ): ColorDef = - UnweightedColorDef(kind) - def apply(kind: ColorKind, weight: ColorWeight): ColorDef = - WeightedColorDef(kind, weight) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala deleted file mode 100644 index ea24372..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorKind.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.model.color - -/** Defines what color should be used, without specifying the area or weight. - */ -sealed abstract class ColorKind private (val name: String): - def apply(weight: ColorWeight): ColorDef = - ColorDef.WeightedColorDef(this, weight) - -object ColorKind: - trait Unweighted: - self: ColorKind => - override def apply(weight: ColorWeight): ColorDef = - ColorDef.UnweightedColorDef(self) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case object current extends ColorKind("current") with Unweighted - case object inherit extends ColorKind("inherit") with Unweighted - // Not present in for all methods - case object transp extends ColorKind("transparent") with Unweighted - // Seen in accent, not preset otherwise - case object auto extends ColorKind("auto") with Unweighted - // Black and white do not have weight - case object black extends ColorKind("black") with Unweighted - case object white extends ColorKind("white") with Unweighted - case object slate extends ColorKind("slate") - case object gray extends ColorKind("gray") - case object zinc extends ColorKind("zinc") - case object neutral extends ColorKind("neutral") - case object stone extends ColorKind("stone") - case object red extends ColorKind("red") - case object orange extends ColorKind("orange") - case object amber extends ColorKind("amber") - case object yellow extends ColorKind("yellow") - case object lime extends ColorKind("lime") - case object green extends ColorKind("green") - case object emerald extends ColorKind("emerald") - case object teal extends ColorKind("teal") - case object cyan extends ColorKind("cyan") - case object sky extends ColorKind("sky") - case object blue extends ColorKind("blue") - case object indigo extends ColorKind("indigo") - case object violet extends ColorKind("violet") - case object purple extends ColorKind("purple") - case object fuchsia extends ColorKind("fuchsia") - case object pink extends ColorKind("pink") - case object rose extends ColorKind("rose") diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala deleted file mode 100644 index 7767ac5..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/ColorWeight.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative.ui.model.color - -opaque type ColorWeight = String - -extension (c: ColorWeight) def value: String = c - -/** Defines weight of a color, eg. 50, 100, 200, etc. - * - * Tailwind-like. - */ -object ColorWeight: - inline given int50: Conversion[50, ColorWeight] with - inline def apply(i: 50) = "50" - inline given int100: Conversion[100, ColorWeight] with - inline def apply(i: 100) = "100" - inline given int200: Conversion[200, ColorWeight] with - inline def apply(i: 200) = "200" - inline given int300: Conversion[300, ColorWeight] with - inline def apply(i: 300) = "300" - inline given int400: Conversion[400, ColorWeight] with - inline def apply(i: 400) = "400" - inline given int500: Conversion[500, ColorWeight] with - inline def apply(i: 500) = "500" - inline given int600: Conversion[600, ColorWeight] with - inline def apply(i: 600) = "600" - inline given int700: Conversion[700, ColorWeight] with - inline def apply(i: 700) = "700" - inline given int800: Conversion[800, ColorWeight] with - inline def apply(i: 800) = "800" - inline given int900: Conversion[900, ColorWeight] with - inline def apply(i: 900) = "900" - inline given int950: Conversion[950, ColorWeight] with - inline def apply(i: 950) = "950" diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala b/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala deleted file mode 100644 index 2b380e6..0000000 --- a/ui/shared/src/main/scala/works/iterative/ui/model/color/package.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.model - -/** We need a generic Color model that can be used both on server and on client. - * - * We have adopted the Tailwind model for now. There is nothing inherently - * Tailwind-specific in the implementation, but all the values are taken from - * their palette and the area model is very HTML biased. - * - * Still, I think that it is a good starting point for exploration and will - * satisfy our current needs. - */ -package object color {} diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala b/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala new file mode 100644 index 0000000..70bebaf --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.model.tables + +/** A column in a table + * + * @param name + * the name of the column, must be unique in a row + * @param get + * a function to get the value of the column from a type + */ +case class Column[A, Cell](name: String, get: A => Cell) + +/** A typeclass to represet a type that can be tabulated into Cells */ +trait Tabular[A, Cell]: + def columns: List[Column[A, Cell]] diff --git a/ui/shared/src/main/scala/works/iterative/ui/services/UserNotificationService.scala b/ui/shared/src/main/scala/works/iterative/ui/services/UserNotificationService.scala new file mode 100644 index 0000000..9a6cc94 --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/services/UserNotificationService.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.services + +import works.iterative.core.UserMessage + +import zio.* + +/** A way for any module to notify the user about a success or failure + */ +trait UserNotificationService: + def notify(level: UserNotificationService.Level, msg: UserMessage): UIO[Unit] + def info(msg: UserMessage): UIO[Unit] = + notify(UserNotificationService.Level.Info, msg) + def warning(msg: UserMessage): UIO[Unit] = + notify(UserNotificationService.Level.Warning, msg) + def error(msg: UserMessage): UIO[Unit] = + notify(UserNotificationService.Level.Error, msg) + def debug(msg: UserMessage): UIO[Unit] = + notify(UserNotificationService.Level.Debug, msg) + def success(msg: UserMessage): UIO[Unit] = + notify(UserNotificationService.Level.Success, msg) + +object UserNotificationService: + enum Level: + case Info, Warning, Error, Debug, Success + + def notify( + level: Level, + msg: UserMessage + ): URIO[UserNotificationService, Unit] = + ZIO.serviceWithZIO(_.notify(level, msg)) + + def info(msg: UserMessage): URIO[UserNotificationService, Unit] = + ZIO.serviceWithZIO(_.info(msg)) + + def warning(msg: UserMessage): URIO[UserNotificationService, Unit] = + ZIO.serviceWithZIO(_.warning(msg)) + + def error(msg: UserMessage): URIO[UserNotificationService, Unit] = + ZIO.serviceWithZIO(_.error(msg)) + + def debug(msg: UserMessage): URIO[UserNotificationService, Unit] = + ZIO.serviceWithZIO(_.debug(msg)) + + def success(msg: UserMessage): URIO[UserNotificationService, Unit] = + ZIO.serviceWithZIO(_.success(msg))